iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Vue.js

從零到一打造 Vue3 響應式系統系列 第 22

Day 22 - Computed:深入緩存機制實作

  • 分享至 

  • xImage
  •  

banner

在上一篇文章中,我們提到將透過「緩存」的機制來解決 computed 在訪問時重複執行的問題。

在 Vue 3 的原始碼裡,computed 是靠一個「髒值標記(dirty flag)」來判斷需不需要重新計算的。

Computed 緩存解決方案

核心邏輯

在 computed 中記錄髒標記:當髒標記是 true,才需要進行更新;當髒標記是 false,則表示需要進行緩存。

class ComputedRefImpl implements Dependency, Sub {
  ...
  ...
  tracking = false

  // 計算屬性是否需要重新計算,如果為 true,則重新計算
  dirty = true

  ...
  ...
  get value() {
    if(this.dirty){
      this.update()
    }
  ...
  ...
  }

  update(){
  ...
  ...
    try {

      this._value =  this.fn()
      // 調用 update 更新後,將 dirty 更改為 false
      this.dirty = false
    } finally {
      endTrack(this)
      setActiveSub(prevSub)
    }
  }
}

再回去看,現在已經有進行緩存,只執行兩次,可是我們又發現了另一個問題,如果你把 index.html 設定為以下:

<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { ref, computed, effect } from '../dist/reactivity.esm.js'

    const count = ref(0)

    const c = computed(() => {
      console.log('computed')
      return count.value + 1
    })

    // effect(() => {
    //   console.log(c.value)
    // })

    console.log(c.value)
    count.value = 1

  </script>
</body>

你會發現 count.value 數值變更之後,他還是訪問 computed,但是依賴 computed 的數值被變更時,我們當下不一定會訪問 computed

查看一下官方程式碼,count.value 數值變更後,computed 沒有被訪問。

day22-01

但是我們的版本,他又再訪問一次computed

day22-02

遇到這個狀況我們可以怎麼做?我們可以做髒標記,等下次computed被 effect 訪問再執行更新。

//system.ts
...
...
export function processComputedUpdate(sub) {
	// 有 sub.subs(effect 鏈表的頭節點),再進行更新
  if(sub.subs){
    sub.update()
    propagate(sub.subs)
  }
}

export function propagate(subs) {
  let link = subs
  let queuedEffect = []

  while (link) {
    const sub = link.sub
    
    if(!sub.tracking){
      if ('update' in sub) {
        // 被 effect 進行訪問,計算屬性需要重新計算
        sub.dirty = true
        processComputedUpdate(sub)
      } else {
        queuedEffect.push(sub)
      }
    }
    link = link.nextSub
  }

  queuedEffect.forEach(effect => effect.notify())
}
...
...

這樣就可以解決緩存的問題。可是我們又發現了新的問題。

Effect 重複執行問題

<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { ref, computed, effect } from '../dist/reactivity.esm.js'

    const count = ref(0)

    const c = computed(() => {
      console.log('computed')
      return count.value * 0
    })

    effect(() => {
      console.log(c.value)
    })

    setTimeout(() => {
      count.value = 1
    }, 1000)

  </script>
</body>

day22-03

現在我們發現,computed 執行兩次,這個沒什麼問題,有問題是 effect 的數值沒有改動,但是它也執行兩次,如果數值沒變,只要執行一次就好了。

day22-04

回顧我們在 Ref 實作,當觸發更新時,也是新值和舊值不相同的時候,才會觸發更新,在這邊我們也用相同作法。

//computed.ts
import { hasChanged } from '@vue/shared'
...
...
class ComputedRefImpl implements Dependency, Sub {
  ...
  ...
  update(){
   ...
   ...
    try {
      // 更新前的值
      const oldValue = this._value
      // 更新的值
      this._value =  this.fn()
      this.dirty = false
      return hasChanged(oldValue, this._value)
    } finally {
      endTrack(this)
      setActiveSub(prevSub)
    }
  }
}

先將更新前的值保存起來,用hasChanged判斷數值是否改變,再從 update 函式返回值判斷:

//system.ts
export function processComputedUpdate(sub) {
	// update 返回值如果是 true
	// 表示數值不同,effect 執行
  if(sub.subs && sub.update()){
    propagate(sub.subs)
  }
}

得到期望結果,computed 執行兩次、effect 執行一次。

day22-05

感覺我們解決了這個問題,但其實發現這個只是非常片面的解決方案,因為 effect 它在訪問相同依賴的時候,會重複觸發。

Effect 訪問相同依賴重複觸發問題

<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { ref, computed, effect } from '../dist/reactivity.esm.js'

    const count = ref(0)

    effect(() => {
      console.count('effect')
      console.log(count.value)
      count.value
    })

    setTimeout(() => {
      count.value = 1
    }, 1000)

  </script>
</body>

day22-06

可以看到這樣它觸發了三次,你如果查看一下 count:

 effect(() => {
	  console.count('effect')
	  console.log(count.value)
	  count.value
})
console.log(count)

day22-07

day22-08

會發現它收集了相同依賴收集兩次,這時候該怎麼解決?

day22-09

原始碼在link函式裡面,每次建立關聯關係前都會去遍歷鏈表,確認是不是有建立過關聯關係。

方法一:在 link 函式判斷是否有建立過關聯關係

export function link(dep, sub) {
	/**
   * 復用節點
   * sub.depsTail 是 undefined,並且有 sub.deps 頭節點,表示要復用
   */
  const currentDep = sub.depsTail
  const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
  // 如果 nextDep.dep 等於我當前要收集的 dep
  if (nextDep && nextDep.dep === dep) {
    sub.depsTail = nextDep  // 移動指針
    return
  }

  /**
   * 如果 dep 和 sub 建立過關聯關係,就直接返回。
   */

  let existingLink = sub.deps; 
  while (existingLink) {
    // 如果在鏈表中找到了與當前 dep 相同的依賴項
    if (existingLink.dep === dep) {
      // 表示這個關聯已經建立過了,直接返回,不做任何事
      return;
    }
    // 移動到下一個依賴項節點
    existingLink = existingLink.nextDep;
  }
  ...
  ...
}
  • link 函式一開始,就先進行檢查。
  • sub.deps (訂閱者的依賴鏈表頭部) 開始進行遍歷。
    • 沿著 nextDep 指標移動,檢查每一個鏈表節點 (Link)。
    • 在每一個節點上,判斷 link.dep 是否與我們正要連結的 dep 是同一個。
  • 如果是,代表已經建立過關聯關係,我們就可以直接 return
  • 如果遍歷完整個鏈表都沒有找到,那才繼續執行後面新增鏈表節點的邏輯。

這邊注意,需要寫在復用節點的邏輯後面:若檢查建立過依賴關係時提前退出,depsTail 標記會一直保持是 undefined,依賴會被錯誤清理。

方法二:重構髒標記

這邊換另一個比較簡易一點的方法,我們不管他們是不是有建立過關聯關係,重點是我們只要讓 effect 函式執行一次就可以了,這樣我們只要調整髒標記處理。

//effect.ts
export class ReactiveEffect {

  ...
  ...
  dirty = true // 是否需要重新計算
	...

我們先在 effect 函式,加一個髒標記。

//system.ts
export function propagate(subs) {
  ...
  ...
    
    // 不在執行中的才加入隊列 以及 他是髒標記是 false 才執行
    if(!sub.tracking && !sub.dirty){
	    // 開始執行,髒標記設定為初始值
      sub.dirty = true
      if ('update' in sub) {
        processComputedUpdate(sub)
      } else {
        queuedEffect.push(sub)
      }
    }
	...
  ...
}

export function endTrack(sub) {
  sub.tracking = false // 執行結束,取消標記
  const depsTail = sub.depsTail
  sub.dirty = false // fn 執行結束,追蹤完
...
...
}

並且在觸發更新時,增加髒標記的判斷。

如果有多個依賴同時觸發這個 effect,它也只會被加入佇列一次。因為一旦 dirty 變成 true,下一次的 !sub.dirty 判斷就會是 false跳過 if 區塊。

endTrack 中,sub.dirty 被設為 false。這代表 effect 剛剛成功執行完畢,它的狀態是「乾淨的」,不需要再次執行。

//computed.ts
..
..
update(){
    ...
    ...
    try {
      // 更新前的值
      const oldValue = this._value
      // 更新的值
      this._value =  this.fn()
			this.dirty = false// 刪除髒標記初始化
      return hasChanged(oldValue, this._value)
    } finally {
      endTrack(this)
      setActiveSub(prevSub)
    }
  }
  ..
  ..

清除在 computed.ts 的髒標記初始化,因為我們已經在 endTrack 函式,統一處理初始化。

day22-10

髒標記的判斷與運作流程

  1. 初始化:一個 effect 在執行完畢後,dirty 標記會被設為 false,表示「這是最新狀態,不需要執行」。
  2. 觸發更新時:當依賴項目變更,propagate 函式會檢查 effect 是否為 dirty: false
  3. 加入佇列前:只有當 dirtyfalse 時,才會將馬上設定為 true,然後再將 effect 加入待執行佇列。
  4. 防止重複:這個「先將設定為 true 再入列」的機制,可以保證在同一個事件迴圈中,縱使有多個依賴項目觸發同一個 effect,它也只會被加入佇列一次,避免了不必要的重複執行。

同步更新《嘿,日安!》技術部落格


上一篇
Day 21 - Computed:即時更新基礎實作
系列文
從零到一打造 Vue3 響應式系統22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言